﻿class Navigator {
    constructor(workflowEle, dotNetHelper, options) {
        this.options = options;
        this.canvasEle = workflowEle;
        this.canvasTransform = this.canvasEle.style.transform;
        this.viewportEle = this.canvasEle.offsetParent;
        this.viewportEle.addEventListener('mousewheel', this.handleMiddleMouseScrollAndZoom.bind(this));
        this.viewportEle.onpointerdown = this.handleViewportPointerDown.bind(this);
        this.viewportEle.onpointerup = this.handleViewportPointerUp.bind(this);
        if (!options.disableMiniMap) {
            this.miniMapEle = this.createMiniMapEle();
            this.viewportEle.appendChild(this.miniMapEle);
            this.miniMap = new MiniMap(this, this.canvasEle, this.miniMapEle, dotNetHelper);
        }
        this.startAnimationFrame();
        this.bestFit();
    }

    handleMiddleMouseScrollAndZoom(e) {
        let scale = this.getCanvasScale();
        if (e.ctrlKey) {
            if (e.wheelDeltaY > 0) {
                scale += 0.1;
            } else {
                scale -= 0.1;
            }
            scale = Math.max(0.1, scale);
            scale = Math.min(3, scale);
            this.zoomTo(scale);
        } else {
            let { left, top } = this.getCanvasBounds();
            if (e.wheelDeltaY > 0) {
                top += 50 / scale;
            } else {
                top -= 50 / scale;
            }
            this.setCanvasLocationAndScale(left, top, scale);
        }
        e.preventDefault();
        e.stopPropagation();
    }

    handleViewportPointerDown(e) {
        if (e.target.closest(`#${this.canvasEle.id}`) || e.target === this.viewportEle) {
            let x = 0;
            let y = 0;
            let firstMove = true;
            this.viewportEle.onpointermove = e => {
                if (e.buttons !== 1) {
                    this.viewportEle.onpointermove = null;
                    this.viewportEle.releasePointerCapture(e.pointerId);
                    return;
                }
                if (Math.abs(x) > 5 || Math.abs(y) > 5) {
                    this.pan(e.movementX, e.movementY);
                    if (firstMove) {
                        this.viewportEle.style.cursor = 'grabbing';
                        this.viewportEle.setPointerCapture(e.pointerId);
                        firstMove = false;
                    }
                } else {
                    x += e.movementX;
                    y += e.movementY;
                }
            };
        }
    }

    handleViewportPointerUp(e) {
        this.viewportEle.onpointermove = null;
        this.viewportEle.releasePointerCapture(e.pointerId);
        this.viewportEle.style.cursor = null;
    }

    createMiniMapEle() {
        const { miniMapWidth, miniMapHeight, miniMapPosition } = this.options;
        const ele = document.createElement('div');
        ele.className = 'minimap';
        ele.style.position = 'absolute';
        ele.style.width = `${miniMapWidth}px`;
        ele.style.height = `${miniMapHeight}px`;
        ele.style.overflow = 'hidden';
        ele.style.backgroundColor = '#eee';
        const margin = '4px';
        switch (miniMapPosition) {
            case 'top-left':
                ele.style.left = margin;
                ele.style.top = margin;
                break;
            case 'top-right':
                ele.style.right = margin;
                ele.style.top = margin;
                break;
            case 'bottom-left':
                ele.style.left = margin;
                ele.style.bottom = margin;
                break;
            default:
                ele.style.right = margin;
                ele.style.bottom = margin;
                break;
        }
        return ele;
    }

    setCanvasTransform() {
        if (this.canvasTransform && this.canvasTransform !== this.canvasEle.style.transform) {
            this.canvasEle.style.transform = this.canvasTransform;
        }
        if (!this.isDisposed) {
            this.setCanvasTransformFrameHandle = window.requestAnimationFrame(() => this.setCanvasTransform());
        }
    }

    startAnimationFrame() {
        this.setCanvasTransform();
    }

    stopAnimationFrame() {
        this.canvasTransform = undefined;
        if (this.setCanvasTransformFrameHandle) {
            window.cancelAnimationFrame(this.setCanvasTransformFrameHandle);
        }
    }

    getNodeBounds(node) {
        let top = 0;
        let left = 0;
        let width = node.offsetWidth;
        let height = node.offsetHeight;
        do {
            top += node.offsetTop || 0;
            left += node.offsetLeft || 0;
            node = node.offsetParent;
        } while (node.id != this.canvasEle.id);
        return { top, left, width, height };
    }

    getCanvasScale() {
        let scale = 1;
        const transform = this.canvasTransform;
        if (transform) {
            const index = transform.indexOf('scale(');
            if (index > -1) {
                scale = parseFloat(transform.substring(index + 6, transform.indexOf(')', index)));
            }
        }
        return scale;
    }

    getCanvasBounds() {
        let left = 0;
        let top = 0;
        const transform = this.canvasTransform;
        if (transform) {
            const index = transform.indexOf('translate(');
            if (index > -1) {
                const offset = transform.substring(index + 10, transform.indexOf(')', index));
                left = parseFloat(offset.split(',')[0]);
                top = parseFloat(offset.split(',')[1]);
            }
        }
        return {
            left,
            top,
            width: this.canvasEle.offsetWidth,
            height: this.canvasEle.offsetHeight,
        };
    }

    getCanvasActualBounds() {
        let left, right, top, bottom;
        const canvasBounds = this.getCanvasBounds();
        const nodes = this.canvasEle.querySelectorAll('.node-content');
        for (const node of nodes) {
            const nodeBounds = this.getNodeBounds(node);
            const offset = { left: nodeBounds.left - 30, top: nodeBounds.top };
            const size = { width: nodeBounds.width + 60, height: nodeBounds.height + 30 };
            left = left === undefined ? offset.left : Math.min(left, offset.left);
            top = top === undefined ? offset.top : Math.min(top, offset.top);
            right = right === undefined ? offset.left + size.width : Math.max(right, offset.left + size.width);
            bottom = bottom === undefined ? offset.top + size.height : Math.max(bottom, offset.top + size.height);
        }
        return {
            left: canvasBounds.left + left || 0,
            top: canvasBounds.top + top || 0,
            width: right - left || 0,
            height: bottom - top || 0,
        };
    }

    setCanvasLocation(left, top, force) {
        this.setCanvasLocationAndScale(left, top, this.getCanvasScale(), force);
    }

    setCanvasLocationAndScale(left, top, scale, force) {
        this.canvasTransform = `scale(${scale}) translate(${left}px, ${top}px)`;
        if (force) {
            this.canvasEle.style.transform = this.canvasTransform;
        }
    }

    getCanvasScaleBounds() {
        const scale = this.getCanvasScale();
        const bounds = this.getCanvasBounds();
        return {
            left: bounds.left,
            top: bounds.top,
            width: bounds.width * scale,
            height: bounds.height * scale,
        };
    }

    getViewportBounds() {
        return {
            left: 0,
            top: 0,
            width: this.viewportEle.offsetWidth,
            height: this.viewportEle.offsetHeight,
        };
    }

    viewportPointToCanvans(point) {
        const canvasBounds = this.getCanvasBounds();
        const canvasScale = this.getCanvasScale();
        return {
            x: point.x / canvasScale - canvasBounds.left,
            y: point.y / canvasScale - canvasBounds.top,
        };
    }

    pan(offsetX, offsetY) {
        const scale = this.getCanvasScale();
        const bounds = this.getCanvasBounds();
        const left = bounds.left + offsetX / scale;
        const top = bounds.top + offsetY / scale;
        this.setCanvasLocation(left, top);
    }

    zoomTo(scale, viewportPoint) {
        const viewportBounds = this.getViewportBounds();
        viewportPoint = viewportPoint ?? {
            x: viewportBounds.left + viewportBounds.width / 2,
            y: viewportBounds.top + viewportBounds.height / 2,
        };
        const canvasPoint = this.viewportPointToCanvans(viewportPoint);
        const left = viewportPoint.x - canvasPoint.x * scale;
        const top = viewportPoint.y - canvasPoint.y * scale;
        this.setCanvasLocationAndScale(left / scale, top / scale, scale);
    }

    zoomIn() {
        this.zoomTo(Math.min(3, this.getCanvasScale() + 0.1));
    }

    zoomOut() {
        this.zoomTo(Math.max(0.1, this.getCanvasScale() - 0.1));
    }

    zoomTo100() {
        this.zoomTo(1);
    }

    bestFit() {
        const viewportBounds = this.getViewportBounds();
        const canvasBounds = this.getCanvasBounds();
        const canvasActualBounds = this.getCanvasActualBounds();
        let scale = viewportBounds.width / canvasActualBounds.width;
        scale = Math.min(1, scale);
        scale = Math.max(0.5, scale);
        const width = canvasActualBounds.width * scale;
        const height = canvasActualBounds.height * scale;
        const left = viewportBounds.left + (viewportBounds.width - width) / 2 - (canvasActualBounds.left - canvasBounds.left) * scale;
        let top = 0;
        if (viewportBounds.height > height) {
            top = viewportBounds.top + (viewportBounds.height - height) / 2 - (canvasActualBounds.top - canvasBounds.top) * scale;
        } else {
            top = viewportBounds.top - (canvasActualBounds.top - canvasBounds.top) * scale;
        }
        this.setCanvasLocationAndScale(left / scale, top / scale, scale, true);
        return scale;
    }

    fitToViewport() {
        const viewportBounds = this.getViewportBounds();
        const canvasBounds = this.getCanvasBounds();
        const canvasActualBounds = this.getCanvasActualBounds();
        let scale = 1;
        if (viewportBounds.width / viewportBounds.height > canvasActualBounds.width / canvasActualBounds.height) {
            scale = viewportBounds.height / canvasActualBounds.height;
        } else {
            scale = viewportBounds.width / canvasActualBounds.width;
        }
        const width = canvasActualBounds.width * scale;
        const height = canvasActualBounds.height * scale;
        const left = viewportBounds.left + (viewportBounds.width - width) / 2 - (canvasActualBounds.left - canvasBounds.left) * scale;
        const top = viewportBounds.top + (viewportBounds.height - height) / 2 - (canvasActualBounds.top - canvasBounds.top) * scale;
        this.setCanvasLocationAndScale(left / scale, top / scale, scale, true);
        return scale;
    }

    getMiniMapVisible() {
        return this.miniMapEle && this.miniMapEle.style.display !== 'none';
    }

    showMiniMap() {
        if (this.miniMapEle && this.miniMap) {
            this.miniMapEle.style.display = 'block';
            this.miniMap.refresh(true);
        }
    }

    hideMiniMap() {
        if (this.miniMapEle) {
            this.miniMapEle.style.display = 'none';
        }
    }

    dispose() {
        this.stopAnimationFrame();
        this.miniMap?.dispose();
        this.viewportEle.onpointerdown = null;
        this.viewportEle.onpointermove = null;
        this.viewportEle.onpointerup = null;
        this.viewportEle.removeEventListener('mousewheel', this.middleMouseZoomHandle);
        this.isDisposed = true;
    }
}

class MiniMap {
    constructor(navigator, canvasEle, miniMapEle, dotNetHelper) {
        this.navigator = navigator;
        this.canvasEle = canvasEle;
        this.miniMapEle = miniMapEle;
        this.dotNetHelper = dotNetHelper;
        this.miniMapViewportEle = this.createMiniMapViewportEle();
        this.miniMapCanvasEle = this.createMiniMapCanvasEle();
        this.miniMapViewportEle.appendChild(this.miniMapCanvasEle);
        this.miniMapEle.appendChild(this.miniMapViewportEle);
        this.miniMapEle.addEventListener('mousewheel', this.handleMiddleMouseZoom.bind(this));
        this.miniMapEle.onpointerdown = this.handlePointerDown.bind(this);
        this.miniMapEle.onpointerup = this.handlePointerUp.bind(this);
        this.refresh(true);
    }

    handleMiddleMouseZoom(e) {
        let scale = this.navigator.getCanvasScale();
        if (e.wheelDeltaY > 0) {
            scale += 0.1;
        } else {
            scale -= 0.1;
        }
        scale = Math.max(0.1, scale);
        scale = Math.min(3, scale);
        this.navigator.zoomTo(scale);
        this.dotNetHelper.invokeMethodAsync('SetZoomAsync', scale);
        this.layout();
        e.preventDefault();
        e.stopPropagation();
    }

    handlePointerDown(e) {
        this.miniMapEle.onpointermove = this.handlePointerMove.bind(this);
        this.miniMapEle.style.cursor = 'grabbing';
        this.miniMapEle.setPointerCapture(e.pointerId);
    }

    handlePointerMove(e) {
        if (e.buttons !== 1) {
            this.miniMapEle.onpointermove = null;
            this.miniMapEle.releasePointerCapture(e.pointerId);
            return;
        }
        this.navigator.pan(-e.movementX / this.viewScale, -e.movementY / this.viewScale);
        this.layout();
    }

    handlePointerUp(e) {
        this.miniMapEle.onpointermove = null;
        this.miniMapEle.releasePointerCapture(e.pointerId);
        this.miniMapEle.style.cursor = 'grab';
    }

    layout() {
        const viewportBounds = this.navigator.getViewportBounds();
        const canvasActualBounds = this.navigator.getCanvasActualBounds();
        const canvasScale = this.navigator.getCanvasScale();
        const globalBounds = {
            left: Math.min(viewportBounds.left, canvasActualBounds.left * canvasScale),
            top: Math.min(viewportBounds.top, canvasActualBounds.top * canvasScale),
            right: Math.max(viewportBounds.left + viewportBounds.width, (canvasActualBounds.left + canvasActualBounds.width) * canvasScale),
            bottom: Math.max(viewportBounds.top + viewportBounds.height, (canvasActualBounds.top + canvasActualBounds.height) * canvasScale),
        };
        globalBounds.width = globalBounds.right - globalBounds.left;
        globalBounds.height = globalBounds.bottom - globalBounds.top;
        const miniMapBounds = {
            left: 4,
            top: 4,
            width: this.miniMapEle.offsetWidth - 8,
            height: this.miniMapEle.offsetHeight - 8,
        };
        const viewBounds = {};
        if (miniMapBounds.width / miniMapBounds.height > globalBounds.width / globalBounds.height) {
            viewBounds.height = miniMapBounds.height;
            viewBounds.width = viewBounds.height * (globalBounds.width / globalBounds.height);
            viewBounds.left = miniMapBounds.left + (miniMapBounds.width - viewBounds.width) / 2;
            viewBounds.top = miniMapBounds.top;
        } else {
            viewBounds.width = miniMapBounds.width;
            viewBounds.height = viewBounds.width / (globalBounds.width / globalBounds.height);
            viewBounds.left = miniMapBounds.left;
            viewBounds.top = miniMapBounds.top + (miniMapBounds.height - viewBounds.height) / 2;
        }
        this.viewScale = viewBounds.width / globalBounds.width;
        this.miniMapViewportEle.style.left = `${viewBounds.left}px`;
        this.miniMapViewportEle.style.top = `${viewBounds.top}px`;
        this.miniMapViewportEle.style.width = `${viewportBounds.width}px`;
        this.miniMapViewportEle.style.height = `${viewportBounds.height}px`;
        this.miniMapViewportEle.style.transform = `scale(${this.viewScale}) translate(${-globalBounds.left}px, ${-globalBounds.top}px)`;
        this.miniMapCanvasEle.style.transform = this.canvasEle.style.transform;
    }

    getNodesBoundsString() {
        if (!this.lastGetNodesBoundsStringTime || Date.now() - this.lastGetNodesBoundsStringTime > 200) {
            this.cacheNodesBoundsString = '';
            const nodes = this.navigator.canvasEle.querySelectorAll('.node-content');
            for (const node of nodes) {
                const nodeBounds = this.navigator.getNodeBounds(node);
                const left = parseInt(nodeBounds.left);
                const top = parseInt(nodeBounds.top);
                const width = parseInt(nodeBounds.width);
                const height = parseInt(nodeBounds.height);
                this.cacheNodesBoundsString += `${left},${top},${width},${height},`;
            }
            this.lastGetNodesBoundsStringTime = Date.now();
        }
        return this.cacheNodesBoundsString;
    }

    createMiniMapViewportEle() {
        const ele = document.createElement('div');
        ele.className = 'minimap-viewport';
        ele.style.position = 'absolute';
        ele.style.transformOrigin = 'left top';
        ele.style.backgroundColor = 'white';
        return ele;
    }

    createMiniMapCanvasEle() {
        const ele = document.createElement('div');
        ele.className = 'minimap-canvas';
        ele.style.position = 'absolute';
        ele.style.transformOrigin = 'left top';
        return ele;
    }

    createNodeOutlineEle(bounds) {
        const ele = document.createElement('div');
        ele.className = 'minimap-node-outline';
        ele.style.position = 'absolute';
        ele.style.left = `${bounds.left}px`;
        ele.style.top = `${bounds.top}px`;
        ele.style.width = `${bounds.width}px`;
        ele.style.height = `${bounds.height}px`;
        ele.style.backgroundColor = '#00000050';
        return ele;
    }

    drawCanvasOutline() {
        this.miniMapCanvasEle.innerHTML = '';
        const fragment = document.createDocumentFragment();
        const nodes = this.navigator.canvasEle.querySelectorAll('.node-content');
        for (const node of nodes) {
            const nodeBounds = this.navigator.getNodeBounds(node);
            fragment.appendChild(this.createNodeOutlineEle(nodeBounds));
        }
        this.miniMapCanvasEle.appendChild(fragment);
    }

    refresh(force) {
        if (this.miniMapEle.style.display !== 'none') {
            let redraw = false;
            let relayout = false;
            const nodesBoundsString = this.getNodesBoundsString();
            if (nodesBoundsString !== this.nodesBoundsString) {
                this.nodesBoundsString = nodesBoundsString;
                redraw = true;
                relayout = true;
            }
            if (this.canvasEle.style.transform !== this.canvasEleTransform) {
                this.canvasEleTransform = this.canvasEle.style.transform;
                relayout = true;
            }
            if (relayout || force) {
                if (redraw) {
                    this.drawCanvasOutline();
                }
                this.layout();
            }
            if (!this.isDisposed) {
                this.animationFrameHandle = window.requestAnimationFrame(() => this.refresh());
            }
        }
    }

    dispose() {
        window.cancelAnimationFrame(this.animationFrameHandle);
        this.miniMapEle.onpointerdown = null;
        this.miniMapEle.onpointermove = null;
        this.miniMapEle.onpointerup = null;
        this.miniMapEle.removeEventListener('mousewheel', this.handleMiddleMouseZoom);
        this.isDisposed = true;
    }
}

export function init(workflowEleId, dotNetHelper, options) {
    const ele = document.getElementById(workflowEleId);
    if (ele) {
        const defaultOptions = {
            miniMapWidth: 200,
            miniMapHeight: 150,
            miniMapPosition: 'bottom-right',
            disableMiniMap: false,
        };
        ele.navigator = new Navigator(ele, dotNetHelper, { ...defaultOptions, ...options });
    }
}

export function zoomTo(workflowEleId, zoom) {
    const ele = document.getElementById(workflowEleId);
    if (ele && ele.navigator) {
        ele.navigator.zoomTo(zoom);
    }
}

export function bestFit(workflowEleId) {
    const ele = document.getElementById(workflowEleId);
    if (ele && ele.navigator) {
        return ele.navigator.bestFit();
    }
    return null;
}

export function fitToViewport(workflowEleId) {
    const ele = document.getElementById(workflowEleId);
    if (ele && ele.navigator) {
        return ele.navigator.fitToViewport();
    }
    return null;
}

export function visibleOrHideMiniMap(workflowEleId, miniMapVisible) {
    const ele = document.getElementById(workflowEleId);
    if (ele && ele.navigator) {
        if (miniMapVisible) {
            ele.navigator.showMiniMap();
        } else {
            ele.navigator.hideMiniMap();
        }
    }
}

export function dispose(workflowEleId) {
    const ele = document.getElementById(workflowEleId);
    if (ele && ele.navigator) {
        ele.navigator.dispose();
    }
}